2025-05-05 리팩터링 시 LSP 원칙 위배사례 발견 및 ReservationPolicyOptions 객체 도입제안
Q. 공통인터페이스에 사용되지 않는 인자 발생
나 "리스코프 치환 원칙"에 관해 물어볼 것이 있어. ReservationPolicy.isReservable 이라는 공통인터페이스의 courtId 인자를 사용하지 않는 Policy가 생겼는데 테스트코드를 작성하다보니 아래와 같이 쓰게 되더라고. 이 경우 추후 치환가능성에 지장이 생기지 않을까 궁금해.
it('월요일 12:00 - 14:00을 예약하는 경우 성공해야한다', async () => {
await expect(
policy.isReservable(
{
from: setUpKstTimeOutUtc(SAMPLE_PARSE_DATE.MONDAY, {
hour: 12,
}),
to: setUpKstTimeOutUtc(SAMPLE_PARSE_DATE.MONDAY, {
hour: 14,
}),
},
'',
mockTicket.id,
),
).resolves.toEqual({ success: true });
});
아래는 TicketPlanPolicy 본문이야:
Injectable()
export class TicketPlanPolicy implements ReservabilityPolicy {
constructor(private readonly ticketRepo: TicketRepository) {}
async isReservable(
time: ITimeDuration,
_courtId: ObjectId,
ticketId: ObjectId,
): Promise<ReservationResult> {
// ?Ticket <<Aggregate Root>> *-- TicketPlan 구조로 가져가야 할까?
// 💡 결론:
// 👉 Ticket은 Aggregate Root, TicketPlan은 외부 참조 모델로 분리하는 것이 좋습니다.
// Ticket이 자신의 유효성을 판단하기 위해 TicketPlan 정보를 읽기 전용으로 참조하는 구조가 적절합니다.
const ticket = await this.ticketRepo.getTicketById(ticketId);
if (!ticket) {
return { success: false, reason: 'TICKET_NOT_FOUND' };
}
const ticketPlan = ticket.ticketPlanModel;
if (!ticketPlan) {
return { success: false, reason: 'TICKET_PLAN_NOT_FOUND' };
}
const isTicketPlanAvailable = isIncludeBusinessDay(
ticketPlan.accessDuration,
time.from.toDate(),
time.to.toDate(),
);
return {
success: isTicketPlanAvailable,
reason: isTicketPlanAvailable ? undefined : 'TICKET_PLAN_NOT_AVAILABLE',
};
}
}
좋은 질문이에요. 이 상황은 리스코프 치환 원칙 (Liskov Substitution Principle, LSP) 이 실제 코드 설계에 어떤 영향을 주는지를 고민하는 좋은 예입니다. 당신의 질문을 정리하면 다음과 같습니다:
ReservationPolicy.isReservable(time, courtId, ticketId)
이라는 공통 인터페이스를 갖는 여러 정책 중,TicketPlanPolicy
는courtId
를 전혀 사용하지 않는다. 그런데 테스트를 작성할 때 어쩔 수 없이courtId
자리에''
를 넣어야 했다. 이것이 LSP 위배일까?
🔍 리스코프 치환 원칙이란?
LSP는 “상위 타입 객체는 하위 타입 객체로 치환해도 프로그램의 정확성이 유지되어야 한다” 는 원칙입니다.
즉, ReservationPolicy
를 의존하는 클라이언트는 TicketPlanPolicy
, CourtBusinessDayPolicy
, HolidayPolicy
등 어떤 구체 구현이 와도 API 사용 방법이나 의미적 일관성이 유지되어야 합니다.
🧠 현 상황에서의 위배 여부 판단
당신의 테스트 코드에서 courtId
에 ''
을 넣는 것은 다음과 같은 위험 신호를 암시합니다:
🚩 1. 의미 없는 인자 (courtId: ''
)
-
TicketPlanPolicy
는courtId
를 전혀 사용하지 않기 때문에, 해당 인자는 의미가 없습니다. -
테스트를 위해서 "일단 빈 값이라도 넣자"는 식의 호출은, 이 API가 불필요한 정보를 요구하는 것처럼 보이게 만듭니다.
-
클라이언트(즉, 사용하는 코드 입장)에서는
"얘는 courtId 안 쓰니까 그냥 아무거나 넣자"
라는 특별한 지식이 있어야만 합니다. 이것이 LSP를 위반하는 패턴입니다.
✅ 해결 방안 제안
1. isReservable()
시그니처를 더 유연하게 만들기
interface ReservabilityPolicy {
isReservable(time: ITimeDuration, options: ReservationPolicyOptions): Promise<ReservationResult>;
}
interface ReservationPolicyOptions {
courtId?: ObjectId;
ticketId?: ObjectId;
}
-
이를 통해
TicketPlanPolicy
는ticketId
만 사용하고,CourtBusinessDayPolicy
는courtId
만 사용하도록 할 수 있습니다. -
타입 시스템 차원에서 옵션 값의 의미를 명확히 하고,
''
같은 임시 값의 사용을 제거할 수 있습니다.
2. 또는 정책을 나눠서 적용하기
- 만약 모든 정책이 동일한 인자를 받아야 한다는 이유가 정책 집합(
ReservationPolicySet
)에서 일괄 호출 때문이라면, 아래처럼 각 정책에 맞는 전략 타입을 분리할 수 있습니다:
interface CourtPolicy extends ReservabilityPolicy {
isReservable(time: ITimeDuration, courtId: ObjectId): Promise<ReservationResult>;
}
interface TicketPolicy extends ReservabilityPolicy {
isReservable(time: ITimeDuration, ticketId: ObjectId): Promise<ReservationResult>;
}
- 이렇게 나누고,
ReservationPolicySet
같은 조합체에서 각 타입별로 정책을 관리하면 됩니다.
✨ 결론
항목 | 판단 |
---|---|
courtId 자리에 '' 을 넣은 테스트는 문제인가? |
예. LSP를 잠재적으로 위반합니다. |
지금의 인터페이스가 LSP에 어긋나는가? | 약간 어긋납니다. 일부 구현체는 특정 인자가 의미 없는데도 강제로 받게 되어 있기 때문입니다. |
해결 방안은? | options 객체 기반 시그니처로 바꾸거나, 정책을 세분화해서 관리하세요. |
필요하시다면 ReservationPolicySet
리팩토링까지도 예시로 보여드릴 수 있어요. 궁금하신가요?
Q. ReservationPolicyOptions 예제
물론입니다. 아래는 ReservationPolicyOptions
를 활용해 ReservationPolicy
인터페이스를 더 유연하고 LSP 친화적으로 바꾸는 예제입니다.
✅ Step 1: 공통 옵션 타입 정의
export interface ReservationPolicyOptions {
courtId?: ObjectId;
ticketId?: ObjectId;
userId?: ObjectId;
// 추가적으로 필요한 정책 옵션을 여기에 확장
}
✅ Step 2: 인터페이스 시그니처 수정
export interface ReservationPolicy {
isReservable(
time: ITimeDuration,
options: ReservationPolicyOptions,
): Promise<ReservationResult>;
}
✅ Step 3: 구현체 예시 – TicketPlanPolicy
@Injectable()
export class TicketPlanPolicy implements ReservationPolicy {
constructor(private readonly ticketRepo: TicketRepository) {}
async isReservable(
time: ITimeDuration,
options: ReservationPolicyOptions,
): Promise<ReservationResult> {
if (!options.ticketId) {
return { success: false, reason: 'TICKET_ID_REQUIRED' };
}
const ticket = await this.ticketRepo.getTicketById(options.ticketId);
if (!ticket) return { success: false, reason: 'TICKET_NOT_FOUND' };
if (!ticket.ticketPlanModel) return { success: false, reason: 'TICKET_PLAN_NOT_FOUND' };
const isAvailable = isIncludeBusinessDay(
ticket.ticketPlanModel.accessDuration,
time.from.toDate(),
time.to.toDate(),
);
return {
success: isAvailable,
reason: isAvailable ? undefined : 'TICKET_PLAN_NOT_AVAILABLE',
};
}
}
✅ Step 4: 클라이언트 코드 (예: 테스트 or ReservationService)
await policy.isReservable(
{
from: dayjs('2025-05-05T12:00:00'),
to: dayjs('2025-05-05T14:00:00'),
},
{
ticketId: mockTicket.id,
},
);
✅ 장점
-
''
같은 더미 값 제거 → 타입 안정성 증가 -
구현체마다 필요한 옵션만 사용 → 리스코프 치환 원칙 만족
-
추후
userId
,academyId
등 정책 조건이 늘어나도 인터페이스 변경 없이 대응 가능
필요하시다면 CompositeReservationPolicy
도 이 구조에 맞게 재작성된 예시 드릴게요. 보여드릴까요?